前言
AQS,即AbstractQueuedSynchronized,抽象的队列式同步器,它是一个用于构建锁和同步器的基础框架,java中很多锁和同步器的实现都依赖AQS,比如ReentrantLock、 ReadWriteLock、Semaphore、CountDownLatch等。然而这些锁都没有直接来继承AQS,而是定义了一个Sync类去继承AQS.因为锁面向的是使用用户,而同步器面向的则是线程控制,那么在锁的实现中聚合同步器而不是直接继承AQS就可以很好的隔离二者所关注的事情.
AQS的实现是基于CLH队列,CLH队列的提出是用于自旋锁,但在AQS中,JUC并发包的作者(Doug Lea)将其用于阻塞锁。在了解AQS之前首先来看看CAS和CLH队列。
CAS
在讲CAS之前,不得不提起乐观锁和悲观锁。乐观锁和悲观锁的区别也看做阻塞同步和非阻塞同步的区别。
悲观锁
悲观锁可以看做是当多个线程竞争同一个资源(比如一个共享数据),其中一个线程最先获得资源的锁时,会认为其他线程对该资源的操作都会对其造成影响,为确保不造成任何影响,将其他线程在获取资源时都阻塞,直到持有锁的线程操作完毕释放锁,唤醒阻塞的线程抢占锁。独占锁是一种悲观锁,synchronized就是一种独占锁。独占锁是一种悲观锁,synchronized就是一种独占锁独占锁是一种悲观锁,synchronized就是一种独占锁。
乐观锁
悲观锁保证了同步互斥,但是线程的阻塞和唤醒(即挂起和恢复)是存在着很大的开销的,如果每个线程执行的操作很短,即持有锁后很快就释放,则其余等待线程就需要频繁的阻塞和唤醒。这样同步互斥的代价就会很高,因此引入了乐观锁,乐观锁也是非阻塞同步,也就是说乐观锁无需阻塞线程来保证同步互斥,乐观锁的思想是每个线程不加锁没有冲突去完成对资源的操作,如果发生冲突(如修改资源时发现已经被别的线程修改)则重试,直到没有成功为止。乐观锁思想的实现有CAS、多版本并发控制(mvcc)等。
CAS
CAS(compare and set,比较和交换)是一种无锁的非阻塞算法的是实现。CAS是CPU指令级的操作,是原子操作。当多个线程同时通过CAS同时修改一个值时,只有一个线程会成功,其他线程都会失败,都是失败线程不会终止,而是尝试再次修改,直到成功为止。
CAS修改一个值的操作涉及到三个变量:内存值V,旧的预期值A,修改的新值B。每一个线程拿着旧的预期值和修改的新值调用CAS,如果旧的预期值和内存值相同,则将新增赋值给内存值,否则什么也不做。
因此每一个线程应用CAS实现的乐观锁算法为:
1 | do{ |
但是CAS存在ABA问题,实际上 JDK 对 ABA 问题提供的解决方案是加入 AtomicStampedReference
这个类,为变量加上版本来解决 ABA 问题。
乐观锁、悲观锁、CAS的参考
https://www.cnblogs.com/Mainz/p/3546347.html
https://blog.csdn.net/truelove12358/article/details/54963791
CLH队列(转载)
CLH队列的提出是用于自旋锁的,从各个自旋锁的发展可以看到CLH自旋锁的由来,以下内容是转载自:https://www.jianshu.com/p/0f6d3530d46b
以 synchronized 为代表的阻塞同步,因为阻塞线程会恢复线程的操作都需要涉及到操作系统层面的用户态和内核态之间的切换,这对系统的性能影响很大。自旋锁的策略是当线程去获取一个锁时,如果发现该锁已经被其它线程占有,那么它不马上放弃 CPU 的执行时间片,而是进入一个“无意义”的循环,查看该线程是否已经放弃了锁。
但自旋锁适用于临界区比较小的情况,如果锁持有的时间过长,那么自旋操作本身就会白白耗掉系统的性能。
以下为一个简单的自旋锁实现:
1 | import java.util.concurrent.atomic.AtomicReference; |
上述的代码中, owner 变量保存获得了锁的线程。这里的自旋锁有一些缺点,第一个是没有保证公平性,等待获取锁的线程之间,无法按先后顺序分别获得锁;另一个,由于多个线程会去操作同一个变量 owner,在 CPU 的系统中,存在着各个 CPU 之间的缓存数据需要同步,保证一致性,这会带来性能问题。
公平的自旋
为了解决公平性问题,可以让每个锁拥有一个服务号,表示正在服务的线程,而每个线程尝试获取锁之前需要先获取一个排队号,然后不断轮询当前锁的服务号是否是自己的服务号,如果是,则表示获得了锁,否则就继续轮询。下面是一个简单的实现:
1 | import java.util.concurrent.atomic.AtomicInteger; |
虽然解决了公平性的问题,但依然存在前面说的多 CPU 缓存的同步问题,因为每个线程占用的 CPU 都在同时读写同一个变量 serviceNum,这会导致繁重的系统总线流量和内存操作次数,从而降低了系统整体的性能。
MCS 自旋锁
MCS 的名称来自其发明人的名字:John Mellor-Crummey和Michael Scott。
MCS 的实现是基于链表的,每个申请锁的线程都是链表上的一个节点,这些线程会一直轮询自己的本地变量,来知道它自己是否获得了锁。已经获得了锁的线程在释放锁的时候,负责通知其它线程,这样 CPU 之间缓存的同步操作就减少了很多,仅在线程通知另外一个线程的时候发生,降低了系统总线和内存的开销。实现如下所示:
1 | import java.util.concurrent.atomic.AtomicReferenceFieldUpdater; |
MCS 的能够保证较高的效率,降低不必要的性能消耗,并且它是公平的自旋锁。
CLH 自旋锁
CLH 锁与 MCS 锁的原理大致相同,都是各个线程轮询各自关注的变量,来避免多个线程对同一个变量的轮询,从而从 CPU 缓存一致性的角度上减少了系统的消耗。
CLH 锁的名字也与他们的发明人的名字相关:Craig,Landin and Hagersten。
CLH 锁与 MCS 锁最大的不同是,MCS 轮询的是当前队列节点的变量,而 CLH 轮询的是当前节点的前驱节点的变量,来判断前一个线程是否释放了锁。
实现如下所示:
1 | import java.util.concurrent.atomic.AtomicReferenceFieldUpdater; |
从上面可以看到,MCS 和 CLH 相比,CLH 的代码比 MCS 要少得多;CLH是在前驱节点的属性上自旋,而MCS是在本地属性变量上自旋;CLH的队列是隐式的,通过轮询关注上一个节点的某个变量,隐式地形成了链式的关系,但CLHNode并不实际持有下一个节点,MCS的队列是物理存在的,而 CLH 的队列是逻辑上存在的;此外,CLH 锁释放时只需要改变自己的属性,MCS 锁释放则需要改变后继节点的属性。
CLH 队列是 J.U.C 中 AQS 框架实现的核心原理,在AQS中将CLH的自旋改为阻塞,下一个节点根据上一个节点的属性上阻塞。
AQS
AQS是通过一个先进先出的双端队列来维护线程同步状态的管理,当线程获得锁失败时,会将线程阻塞并加入到队列的末尾,队列头持有锁,如果头结点释放锁会将后继节点释放,并作为头结点。
Node
既然是队列,我们先来看看队列中的节点(node),队列中的节点存放的是一个个线程,其实AQS中并没有维护一个明确的队列,是通过各个节点存放的next和prev指针记录队列中的位置,AQS维护着head和tail来存放队列中头和尾。
1 | private transient volatile Node head; |
Node的状态有5种,分1,-1,-2,-3,分别代表的意义:
- CANCELLED(1):表示该节点的线程因为超时或者中断而退出,节点一旦进入该状态就不会转移到其他状态,相应的节点会在队列中被移除。
- SIGNAL(-1):表示其后继节点将会被挂起,在当前节点释放锁后,其后继节点会被唤醒。
- CONDITION(-2):当前节点处于条件等待状态,不会被当成队列中的节点,直到被唤醒,其值设置为0,进入阻塞状态
- PROPAGATE(-3):传播共享锁
- 0:新加入队列的节点
nextWaiter来标记节点是否是共享模式。
同步状态
AQS内部的同步状态是通过state表示,state被volatile修饰,其修改对所有线程可见。
1 | private volatile int state; |
同步状态 state的具体语义与具体使用者有关。如在Semaphore中state表示可获取的信号量,当调用acquire方法成功获取一次后state值将加一,用完调用release方法释放后state的值将减一 ; 在CountDownLatch中 state表示调用await的方法的线程还要等待调用多少次countDown方法,该线程才能继续执行; 在ReentrantLock中state表示,线程调用lock方法的次数。
模板模式下,AQS子类通过设置state来实现获取锁acquire和释放锁release的方法。可以看下文的AQS的使用看到用法。
独占模式
在获取锁的时候,分为独占模式(exclusive mode)和共享模式(shared mode),独占模式是只有一个线程能获取到锁,但是共享模式可以允许多个线程同时获取到锁。比如ReentrantLock就是一个独占锁,只能有一个线程获得锁,而WriteAndReadLock的读锁则能由多个线程同时获取,但它的写锁则只能由一个线程持有。 ASQ类使用了模板方法设计模式,具体的如何获取锁和释放锁是交由子类具体实现,AQS中只有获取锁和释放锁时队列中节点的变化和队列的调整。接下来先来看独占模式下锁的获取和释放。
获取锁
1 | public final void acquire(int arg) { |
这里首先尝试获取锁,tryAcquire交由子类实现,如果获取锁失败,则将其加入到等待队列中,并设置为独占模式。
1 | private Node addWaiter(Node mode) { |
可以看到首先获得队列尾部节点,尝试用CAS插入节点,如果队列尾部节点为空(即队列中节点)或者通过CAS时发生冲突失败(即在插入当中有别的元素插入,队尾元素改变),则调用enq插入。
1 | private Node enq(final Node node) { |
enq方法尝试通过CAS乐观锁算法循环插入,直到成功为止,如果队列中无节点,则设置为队列头。在加入到等待队列过程中,可能已经有线程释放锁,还需要判断线程是否能重新获得锁,否则需要挂起,即阻塞线程。
1 | final boolean acquireQueued(final Node node, int arg) { |
如果没获取到锁,则判断是否应该挂起,而这个判断则得通过它的前驱节点的waitStatus来确定:
1 | private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { |
首先判断其前驱节点是否是挂起状态,如果是则直接得到需要挂
如果不是则判断是否是CANCELLED(1)状态(大于0),是则将前驱队列从队列中删除,找到一个不为CANCELLED状态的节点作为前驱节点,返回false,等待acquireQueued的下一次循环挂起
如果不是,则将前驱节点设置为挂起状态,返回false,等待acquireQueued的下一次循环挂起
真正挂起线程在parkAndCheckInterrupt函数中,其中调用了LockSupport的park挂起线程,只有通过shouldParkAfterFailedAcquire判断需要挂起线程,才会调用parkAndCheckInterrupt。
1 | private final boolean parkAndCheckInterrupt() { |
可以看到线程在走到这里被操作系统挂起,直到系统唤醒才会继续执行,继续执行将会回到acquireQueued方法中的for循环,判断前驱节点是否为头结点并尝试获得锁。在系统挂着线程是无法中断的,只能是线程被唤醒,查看自身的中断状态,才知道是否被中断,如果中断,则最后会调用selfInterrupt方法。
总的来说,AQS中解决通过问题都是通过CAS乐观锁算法解决,获取锁的过程是
- 首先尝试获取锁tryAcquire,如果失败则需要添加到队列尾部
- 队列尾部通过CAS循环插入
- 插入到尾部的线程需要判断前驱是否为头结点,是则尝试获取锁
- 获取锁失败或者前驱不是头结点,则需要挂起线程,找到一个前驱不为CANCELLED的节点,如果不为挂起状态,则将前驱设为挂着状态,接着通过LockSupport挂起当前线程
- 线程等待唤醒,如果唤醒,则继续acquireQueued中的for循环,判断前驱节点是否为头结点,是则tryAcquire尝试获得锁,所以所有线程获得锁都必须通过子类编写的具体的tryAcquire方法
- 其中如果唤醒线程发现被中断,则acquireQueued返回结果为false,调用selfInterrupt中断
释放锁
1 | public final boolean release(int arg) { |
首先通过模板方法的子类实现的tryRelease释放锁,接着讲后继节点变为头结点,通过unparkSuccessor唤醒头结点的线程。
1 | private void unparkSuccessor(Node node) { |
如果头结点不为空,且不为CANCELLED状态,则直接通过LockSupport的unpark唤醒节点,否则需要从尾部开始向前寻找第一个不为CANCELLED状态的节点唤醒。
为什么不往下(next)寻找可用的节点,而是从尾部往前寻找,因为next的节点也可能为空或者CANCELLED状态。
共享模式
共享模式的实现和独占模式差不多,有时间会继续更新
LockSupport
LockSupport是唤醒和阻塞线程的类,底层是通过UNSAFE类调用系统内核进行线程的阻塞和唤醒。
LockSupport中parks是阻塞线程方法(有很多重载方法,可以设置等待时间等), unparks是唤醒线程。
AQS使用
jdk的AQS类中给出了一个使用AQS实现互斥锁的简单例子。
1 | class Mutex implements Lock, java.io.Serializable { |
Mutex通过修改AQS的state来标志锁是被持有还是被释放。注意lock和tryLock是不同的,这两个方法都会尝试获取锁,但是lock调用AQS的acquire方法,未获取的锁,会进入CLH队列中挂起等待轮到头结点唤醒,tryLock调用tryAcquire方法,未获取到锁,不会被挂起。
1 | public class MutexTest { |
可以看到结果,线程2在线程1释放锁后获得锁
1 | thread1 running now:Wed Mar 13 21:53:04 CST 2019 |
参考
《深入理解java虚拟机》